Repository navigation
feat(webui): add the Browser Use surfaces, bridge and panel - #195
Conversation
The model picker added two keyframe animations and a reduced-motion override, and the Browser Use panel added a stylesheet to the client's composition. The frozen lists in the stylesheet-structure gate still named the old set, so the gate failed on the branch it was supposed to protect. The two model animations and the twelfth reduced-motion override are the model picker's; the stylesheet entry is the browser panel's.
The WebUI gains a Browser panel: it shows a browser page as a live image
stream rather than an iframe into the target site, and forwards the
person's pointer, keyboard and text input into that same page, so the
Agent's browser tools and the human operate one browser session.
Three surfaces, four independent switches:
* the isolated Chromium this process launches and owns;
* the person's Edge, reached through the extension bridge;
* the person's Chrome, reached through the extension bridge;
* and a separate Full CDP switch that widens the command set of an
already-authorized surface without authorizing one.
Every switch is off unless the configuration says otherwise, and the
switches are independent: allowing the isolated browser never implies
allowing the person's own.
The external browsers are reached through a dedicated loopback path
that is not the WebUI's own WebSocket and is not admitted by the generic
dev-mode bypass. Pairing is challenge-response, each browser gets its
own high-entropy credential, and the credential lives in the operating
system's secure store; when no store is available the pairing fails
explicitly rather than writing the secret to a file.
A target's identity carries a generation, so an input or a frame
recorded against a page that has since been replaced is refused
instead of landing on whatever took its place.
…lifecycle
The browser panel was built but not reachable, and the bridge switched every
external surface on regardless of the switches.
The panel:
* The browser business now publishes a host component that owns the browser
state, drives the port and renders the panel. The workspace may mount that
published surface but may not construct another business's state, which is
what the dependency rules require.
* The workspace gains a browser tab, its add-menu entry and the mount
branch, and the tab registry's frozen list grows with it.
The bridge:
* The bridge no longer switches a surface on by itself. The pairings follow
the switches, because the switches own them: the service reads the
persisted authorization and enables exactly the surfaces whose switch is
on, each independently. A surface that is off holds no credential, admits
no session, and refuses its old credential from then on.
* The runtime reaches the bridge through the member the bridge actually
exposes, so the Edge and Chrome surfaces resolve to the live extension
session instead of to an undefined factory.
* An external frame keeps the generation it carries. A frame whose
generation does not match the latest announcement is dropped rather than
relabelled, so a delayed frame from the previous page cannot be shown as
the current one.
* External target generations reach the runtime registry, so a target that
navigated stays usable at its new generation.
…e frame queue A host that restarted minted a fresh pairing credential, so it and the extension ended up holding different secrets and the extension could never answer a challenge the host issued. The credential is now restored at startup rather than rotated: the extension keeps its copy in chrome.storage.local across restarts, so the host has to keep its side too. Turning a switch off still revokes the credential and removes it from the store, so the next enable mints a fresh one — the two paths are different on purpose. The live image stream also kept every frame it had queued while the consumer was behind. A frame that is already superseded when it is read is not worth delivering, so the queue keeps the newest frame per target.
Review recordThis change went through three independent reviews, each by a
The reviews earned their keep. Three defects that the first two rounds
What a reviewer should check first
|
…nge the credential Three review findings from the third round, and the two open ones after it. The Agent's tools now drive the same browser session as the panel. A `LocalBrowserAdapter` over the browser service forwards a tool action to the service, which reads the surface switch and the Full CDP gate — so the authorization is decided in one place and the tool path does not carry a second notion of it. An action outside the restricted set is refused even when Full CDP is on, because the tool path is the restricted path by construction. The target selection is scoped to the run that made it. It is keyed by `(surface, runId)`, where the run is the Agent session id: the tool path passes `ctx.sessionId`, and the panel sends the active run's id in the operation body because the wire frame carries none. Two runs on one surface keep their own selections, each run's image stream shows its own target's frames, and a run with no selection is refused rather than falling back to another run's. The host and the extension now share a pairing credential. The extension generates an ephemeral P-256 ECDH key pair per connection and sends the public key in its `hello`; the host answers with a `pair` frame carrying the credential encrypted to it — ECDH, then HKDF-SHA256, then AES-256-GCM — with the host's own ephemeral public key, the salt and the ciphertext. The secret never crosses the wire in the clear, and no person has to carry a code. Both sides pin the same derivation vector, so the two implementations cannot drift apart.
Follow-up: three more findings closedA third review round found six issues. Three more have now been fixed and the tree is
The decision and its limits — including that the extension's identity rests on an Ticket stateClosed: #177, #178, #179, #180, #181, #182, #183, #185, #191. The one review finding still open is Full CDP on the external surfaces: the Gates
|
… stale claims A fourth review round found that the extension's allowlisted `Origin` does not authenticate an extension: a local process can put that origin on a loopback socket, supply its own ephemeral key, and receive the pairing credential the host seals to it. The exchange protects the credential in transit and nothing more. It is not an authentication of the peer. The decision record now says that plainly, names the stronger anchor the gap needs — an installation key the extension holds and a local process does not — and stops short of calling the allowlist a boundary against a local process. The same round found two claims in the published evidence that the tree had outgrown: that the Agent-tool adapter did not exist, and that the host and the extension still minted independent credentials.
…ollow its surface A fourth review round found that the image stream stayed attached to the target that was selected when it started, so selecting another tab changed which frames passed the fence without changing what the browser was capturing; and that the Agent tool path defaulted to the isolated Chromium even when the person was looking at Edge or Chrome. The stream now re-reads the run's selection on each pass and restarts the provider's capture when the target changes, stopping the previous target's screencast so it is not left running for a tab the person moved away from. The identity and generation fences still decide which frames pass, and one run's retarget does not disturb another's. The tool path no longer picks a browser for itself. It asks the service which surface the run is working on, which is derived from the run's own selection — the same state the panel writes — and a run that has selected nothing is refused with a stable browser error code rather than being sent somewhere the person is not looking.
The panel opens its image stream once per surface and run. Opened before the run has selected anything, the service refuses it — which is required — and the panel then shows no live frames until the surface or the run changes. The client needs to re-subscribe after the first successful selection.
Review round 4A fourth
The one that stays open, and whyThe bridge is not authenticated against a local process. Admission selects the The exchange protects the credential in transit and nothing more. It is not an Until that exists, the switch that enables an external surface enables it for the Gates
|
The panel opened its image stream once per surface and run. Opened before the run had selected anything, the service refused it — which is the required behaviour — and the panel then showed no live frames until the surface or the run changed. The subscription is now tied to the run's selected target: picking a target opens the stream, and the run's target moving closes the old subscription and opens one for the new target. The decision is a pure function in the projection, so it is covered by a unit test — the client test environment has no DOM renderer to drive the effect directly.
…n load A browser extension runtime executes JavaScript only. The extension's source is TypeScript, so loading the source directory registered a service worker that then could not run — the extension looked installed and did nothing. No review round caught it, because every test of the extension runs against its TypeScript source. A probe against a real Chrome showed it: the source directory's worker registered and its service worker URL ended in `.ts`. The extension now has a build step that bundles the TypeScript to JavaScript and emits a `dist/` whose manifest points at the built paths. Re-probed against the same Chrome, the built extension registers, parses, runs and answers an evaluation. The source `manifest.json` stays the single declaration of the extension's metadata; the build only rewrites the two paths that point at source.
Admission selected the allowlisted extension id from the `Origin` header,
and the host then sealed its pairing credential to the public key that
peer supplied. A local process can put any `Origin` on a loopback
socket, so it could be admitted as the allowlisted extension, supply
its own key, decrypt the credential and answer the challenge — the
exchange protected the credential in transit and did nothing else.
The peer now has to prove possession of an **installation key** before
it receives anything:
* The host generates a long-lived, high-entropy key per surface and
keeps it in the OS secure store. It is never written to a file, a
log, an error message or a process argument.
* The extension holds the same key and proves possession of it when
it answers the host's challenge.
* Only a peer that has proved the installation key is sent the
sealed pairing credential. A peer that cannot is refused, and no
credential crosses to it at all.
* Turning a surface off revokes the pairing credential but keeps the
installation key, so the person does not have to provision again
after a toggle.
The ECDH derivation, the HKDF info string and the shared test vector
are unchanged: the key changes who may receive a credential, not how it
is sealed.
The operation that surfaces a browser's installation key needs the bridge, and the registry only learns about it when the composition root passes it. Both roots now hand the bridge over, so the key is reachable from the Settings surface instead of the operation answering not_provisioned.
The bridge no longer treats an allowlisted Origin as the peer's identity. A peer must prove possession of the surface's installation key before the host releases a pairing credential, and the handshake is proof-first: a peer that proves neither the installation key nor a credential the host already issued receives nothing at all. The decision and its cost are recorded in the ADR's amendment A2 — provisioning is explicit, because the automatic pairing the record describes is automatic with respect to tabs, not with respect to peer identity.
The peer-identity blocker is closed; the extension now runs in a real browserTwo things changed since the last update, and both were found by probing the real The extension could not actually runEvery test of the extension ran against its TypeScript source, so nothing The extension now has a build step that bundles its TypeScript to JavaScript The bridge is authenticated by an installation keyAdmission selected the allowlisted extension id from the The peer now has to prove possession of the surface's installation key — a The handshake is proof-first: The ECDH derivation, the HKDF info string and the shared test vector are The cost is recorded rather than glossed: provisioning is explicit, because the Gates
Still open#184 — Full CDP on the external surfaces is refused, because #193 — the external path has still not been driven end to end against a real |
The external path had only ever been exercised against a synthetic peer. A Playwright spec now loads the **built** extension into a real Chrome and points it at a real bridge server. It proves the half that matters: an extension that cannot prove the installation key is **refused** — the host reports the surface as configured and enabled but not connected, and no credential crosses to the peer. It does not yet prove the happy path. The same extension, with the key provisioned, never opens a connection, and it records no failure of its own, so the service worker that should answer the challenge is either not starting after the reload or is starting without the origin it was given. That test is marked `fixme` rather than deleted, so the gap stays visible and the test runs as soon as it can. The consequence is recorded in the published evidence: the external path is not yet demonstrated working in a real browser, and the capability matrix keeps the external surfaces marked *not measured* rather than *working*.
The spec used a storage method the extension API does not have, and Playwright 1.63's test.fixme takes a condition rather than a body. With both corrected the refusal test passes against a real Chrome and the happy path is reported as the known gap it is.
The external path, against a real browserThe external path had only ever been exercised against a synthetic peer. A What it proves. An extension that cannot prove the installation key is What it does not yet prove. The happy path — the same extension, with the What this means. The external path is not yet demonstrated working in a Gates
|
…he key is pasted A Manifest V3 service worker is lazy: it starts on an event and is torn down when it goes idle. Saving the installation key did not wake it, so the extension sat there configured but unconnected, and the options page told the person to "reconnect the extension to pair with the host" — a manual step nobody checks. The options page now asks the worker to reconnect, and the worker answers by stopping the current client and starting a new one against the freshly stored configuration. The real-browser spec found this, and it now passes both halves against a real Chrome with the built extension loaded: the handshake completes once the extension can prove the installation key, and an unprovisioned peer is refused and receives no credential.
The external path now works against a real ChromeThe real-browser spec passes both halves:
The defect it foundA Manifest V3 service worker is lazy: it starts on an event and is torn The options page now asks the worker to reconnect, and the worker answers by Gates
Still open#193 stays open for one reason only: Edge was not exercised. Edge is not #184 — Full CDP on the external surfaces: the bridge needs one more frame |
Edge is installed on this machine — `/usr/bin/microsoft-edge`, 151.0.4129.107 — and an earlier note in the published evidence said otherwise. That was wrong, and the parity it implied had not been tested. The acceptance now runs against both the bundled Chromium and the installed Edge, and all four cases pass: in each browser the handshake completes once the extension can prove the installation key, and an extension that cannot is refused and receives no credential. The published evidence is corrected. What is *not* established is Edge's DevTools domain parity with Chrome's — ADR 0019 decision 6 forbids claiming identical CDP domains across surfaces — and ticket #193 stays open for the store artifacts, the per-build identity validation and the disclosures rather than because Edge was unavailable.
The bridge refused every command on the external surfaces, because `chrome.debugger` exposes only a subset of the DevTools domains and pretending otherwise would be a lie. The host now sends a raw DevTools command as a `cdp` frame, and the extension answers with a `cdp-result`. The extension keeps an explicit list of the commands it will send and refuses anything outside it before it makes any browser call; a refusal says *unsupported*, so the host can tell "the extension will never run this" from "this failed". The host's own gate is unchanged and still comes first: the surface must be authorized, and the command must be a restricted browser-tool action or permitted by the Full CDP switch. The provider does not keep a second opinion about that, which is why it no longer refuses a raw command on the surface's own authority. A command acts on a page, so a page has to be selected first. The command operation now carries the run it acts for, and acts on that run's selection — the same one the panel and the Agent's tools use — so the three cannot drift apart. Routing is the input path's: the same ownership check, the same per-target serialization, the same refusal of a stale generation.
…uite list The source inventory refuses a first-party test that the suite list does not name, so the new test had to be recorded before the inventory could be regenerated.
The helper's declared type was narrower than the target identity the provider reports, so the typecheck rejected a value that was correct.
Full CDP on the external surfacesThe bridge refused every command on Edge and Chrome, because The host now sends a raw DevTools command as a The host's gate is unchanged and still comes first: the surface must be A command acts on a page, so a page has to be selected first. The command Gates
Still open#193 — the store artifacts: a Chrome Web Store build and an Edge |
…documents The acceptance launched both Chrome and Edge, but both runs configured the **Chrome** surface: the same allowlist, the same pairing, the same credential. So the Edge run proved that an Edge-hosted extension can complete the handshake — it did not prove that the Edge switch, the Edge pairing credential or the Edge routing work. The acceptance is now parameterised by surface. The Chrome run is admitted on the `chrome` surface and the Edge run on the `edge` surface, each with its own allowlist, its own pairing and its own credential, and each asserts the matching surface's state. Parameterising it exposed a real bug: the installation key was read from a hard-coded surface, so the Edge run had no key to paste. The published documents also contradicted the tree — the capability matrix, the bridge protocol and the admission document still described the bridge as unwired, and this record contradicted itself about which browsers had been exercised. They are reconciled.
A fifth review, and two things it caughtThe fifth The Edge run was authenticating the Chrome surfaceThe acceptance launched both Chrome and Edge — but both runs configured the The acceptance is now parameterised by surface. The Chrome run is admitted All four cases pass, and each browser is now admitted on its own surface. The published documents contradicted the treeThe capability matrix, the bridge protocol and the extension admission Gates
|
The handshake acceptance proved that an extension can pair. It did not
prove that the bridge works: that a keystroke the host sends arrives in
the page the person is looking at, that the page's image reaches the
host, or that a DevTools command runs against the live tab.
A new acceptance drives all three through the real bridge, in real Chrome
and real Edge, each on its own surface, against a page served over
loopback — the extension deliberately leaves policy-blocked URLs out of
its targets, so a `data:` URL would have proved nothing.
It found four real defects, all now fixed:
* The extension's frame parser had no `cdp` case, so a DevTools command
was rejected as an unknown frame kind and the client closed the socket.
* A refusal was converted to a transport failure too early, so the
provider never saw it and the stable error code the contract promises
never applied. A refusal is an *answer*, not a failure.
* The provider discarded the command's result, so a caller had no way to
read what the command produced.
* The extension could not capture the page at all: `captureVisibleTab`
needs the `<all_urls>` host permission or an `activeTab` permission
granted by a user gesture, and there is no gesture here — the host is
asking for the picture. The permission is now declared and the reason
is recorded in the extension's README.
The external path, driven end to end in a real browserThe handshake acceptance proved that an extension can pair. It did not prove A new acceptance drives all of it through the real bridge, in real Chrome and It found four real defects, all now fixed:
Gates
TicketsClosed: #177, #178, #179, #180, #181, #182, #183, #184, #185, #186, #187, Still open: #190, #192, #193, #194 — the OS secure store's macOS and Windows |
The bridge's survival across a worker restart was an assertion about the code's shape. It is now an observation: the acceptance closes the extension's service-worker target — what the browser does when it reclaims an idle worker — while an image stream is running. The host sees the session drop, the worker is woken by the next event, and the bridge is usable again, with a DevTools command against the live tab answering afterwards. In both Chrome and Edge.
…the disclosures A store assigns an extension's id when the extension is first published. An unpacked build's id is derived from the directory it is loaded from, so it is different for every path and is therefore not an identity anyone can rely on. The build's `--store` mode embeds the store's public key in the manifest, which fixes the identity, and produces a zip for that store. A **packaged host** reads the store allowlist and never the development one, so it cannot admit an unpacked build by accident; a development host reads the development allowlist. Both rules are tested. The identity is verified against the browser rather than asserted: a packaged build loaded in Chrome gets exactly the id its key determines — the id computed from the key and the id read off the loaded service worker are the same string. The disclosures both stores ask for are written down in `packages/browser-extension/STORE-DISCLOSURES.md`: the single purpose, what is sent to whom, what is stored and where, each permission and why it is needed — including why `<all_urls>` is required for the picture of the visible tab — and exactly how a packaged build differs from a development one.
…ntory The packaged archives are named after the directory they are built into (dist-chrome.zip, dist-edge.zip). The inventory scan skips build directories by name but not these files, so it recorded an archive the export does not carry, and the export gate failed with a difference no one could act on. They are build outputs — the store takes the archive the build produces, not a committed file.
A packaged build and a development build cannot share one directory: the packaged build's manifest carries the store's public key and the development build's does not, and a build that overwrote the other would make the loaded extension's identity depend on which suite ran last. The build takes --out now, and the source inventory skips the store build directories the same way it skips the store archives beside them.
The acceptance matrix has to say which browser, which operating system, which settings and what was observed. A matrix written by hand drifts from what the suites did, so the suites write the record while they run and the published matrix is written from it. A failing case is recorded as a failure before the error is re-thrown, and each suite replaces its own file on the first record of a run, so a re-run cannot leave a case behind from the previous one. The two real-browser specs now share the harness rather than each carrying a copy of it.
…is measured The bridge could read the store allowlist, but no entry told it to: both composition roots built it as a development host, so a packaged WebUI accepted development identities — the one thing a packaged build exists to refuse. An unpacked build's id is derived from the directory it was loaded from, so it is not an identity anyone can rely on. The npm entry now passes packaged: true, and the development entry is the one that reads the development allowlist. The new acceptance measures the difference against a real browser: the id a store build gets is the one its public key determines, a packaged host admits it through the store allowlist, and the same packaged host refuses it when the id is named only in the development allowlist.
…r reaches a log Four of the cases the acceptance names had no test at all: an oversized frame, a malformed frame, a duplicate connection from the same extension, and a revocation while an action is in flight. They run against a real ws server and a real client now, and the host survives the first two. The credential lifecycle ticket asks for a test that observes a credential never appearing in captured log output. The whole pairing now runs with the console and both process streams captured, and the host's own stored secrets are watched in every form they could take. None of them appears in the output, and none appears in the frames the host sent.
The matrix says which browser, which operating system, which settings and what was observed, for every surface: the isolated Chromium, Edge and Chrome. It is written from the records the suites produce, and it names what was not measured — the macOS and Windows credential-store commands, the store submission, and the Agent's own path against a real browser. The review record gains a seventh round: the findings the final acceptance pass raised, and what was done about each.
…he record The packaged acceptance now runs for both stores: the Chrome Web Store build in Chrome and the Edge Add-ons build in Edge, each from the one shared source. For each, the id the browser assigns is the id the public key determines, the two builds differ in the manifest's key and in nothing else, a packaged host admits the build through the store allowlist, and the same packaged host refuses it when the id is named only in the development allowlist.
…o that surface's own secret An unpacked build has one identity, and a person who loads it in both Edge and Chrome lists that identity on both surfaces. The admission accepted the origin if any pairing admitted it and stopped at the first match, so a Chrome browser landed on the Edge pairing: the Edge surface reported connected while the person was looking at Chrome, and the Chrome surface never came up. The peer now names the browser it runs in, and the host answers with the pairing for that browser. The name only selects which secret has to be proved — each surface has its own installation key and its own credential, the challenge comes from that surface's pairing, and the proof is checked against that pairing's own secret. A peer that calls itself Edge and answers with the Chrome key is refused. Also, with the surface known: - a peer that never opens with a hello is closed after fifteen seconds, rather than holding the socket for as long as it likes; - a surface is one browser: a second live session on it would be a browser the host cannot address, so a different instance is refused with a clear reason, while the same browser reconnecting — a Manifest V3 worker that restarts — still takes over; - target ownership carries the surface, and a command that names a target owned by the other surface is refused with the mismatch named.
… says what it observed The adversarial suite now covers the two cases the surface fix turns on: a peer that names a browser it cannot prove is refused, and a second browser on a surface that is already served is refused with a reason. The acceptance record reads the switch states from the host and reads them after the case body has run, so the record describes the state the observation was made in. The file is emptied at the start of a run, so a run that fails during setup leaves an empty file rather than the previous run's passes. The secrecy test checks every frame the host sent rather than the one that carries the credential, and replaces eight console methods rather than five.
The package verification already built the archive, installed it and started the real process. It now also asks that process's bridge to admit an identity: the store identity is admitted on its /browser-bridge path, and the development identity is refused, with both allowlists configured at once. That is the shipped process running the shipped configuration, rather than a bridge the test built for itself.
The store disclosure said the extension never reads the page's contents while its own DevTools subset includes DOM.getDocument, Runtime.evaluate, Accessibility.getFullAXTree, Network.getCookies and Network.getResponseBody. It now says what the subset can read, and the <all_urls> permission row and the privacy policy agree with it: a disclosure that understates the extension is worse than one that does not. The capability matrix, the bridge protocol and the authorization trace had more status claims that the tree had outgrown — the bridge admission '"unwired"', the reconnect policy '"unwired"', the matrix's '"one surface was measured"'. They are corrected against the tree.
The source-history scan flagged the published pairing transfer vector twice: the constant was called a secret, and the value is a 32-character hex string next to that word. The bytes are 0x00, 0x11, 0x22, … and the ciphertext above them is pinned to them, so the value cannot change — only the name can. They are called what they are: the plaintext the payload carries, in hex. The scan configuration also names the vector explicitly, scoped to the two test files and to that one value, so quoting it there does not break the scan.
The acceptance drives the *installed* Edge, and the bundled Chromium that the gate installs itself. A machine without Edge — a CI image that does not ship it, a container — cannot run the Edge cases at all, and failing them there would say the bridge is broken when the browser is simply absent. They skip with the reason instead. The acceptance record names the browser each case ran in, and the published matrix names the host, so a skipped case is visible rather than silent.
The session declared a ping cadence, held the pending-ping table and answered a pong — and never sent a ping. The option's own comment claimed otherwise, which is the kind of claim the review record keeps catching. A WebSocket can stay open long after the browser behind it is gone: the extension's worker is terminated, the profile is closed, the machine sleeps. The socket's close is then the only signal, and it can take a while to arrive. The host now pings an admitted session every ten seconds and closes it when the pong does not come, so the extension's reconnect ladder picks the session back up instead of the panel showing a browser that is no longer there. The extension already answered a ping with a pong, so nothing on that side changed.
Three amendments, each one a decision the implementation made because the original text did not settle it: - A5 — the surface is settled by the browser, and bound to that surface's own secret. An unpacked build has one identity, so the identity alone cannot say which browser is connecting; the peer names the browser it runs in, and the proof is still checked against that surface's own installation key. - A6 — a packaged host accepts store identities and never development ones. An unpacked build's id is derived from the directory it was loaded from, so it is not an identity anyone can rely on. - A7 — a surface serves one browser. The host has one provider per surface, so a second live session would be a browser the host could not address. The same browser reconnecting — a Manifest V3 worker that restarts — still takes over.
The ninth codex review read the tree the eighth round left behind and raised two findings. Both were real, and both are fixed on the branch: - The session declared a liveness ping and never sent one. It held the pending-ping table, it answered a pong, and its own option comment said the session emits a ping — but no ping was ever written to the socket. An admitted session now pings every ten seconds and closes when the pong does not come within one interval, so a browser gone behind an open socket is noticed and the extension's reconnect ladder takes the session back up. - The real-browser acceptance failed on a machine without the installed Edge, reporting a broken bridge rather than an absent browser. Those cases skip with the reason now. The document also stops carrying two rounds with the same numbers: the two probe rounds are Probe A and Probe B, and the counts in the header, in the directory index and in the closing evidence say ten review rounds and twenty-one acceptance cases.
The Linux profile drives the real-browser suite, and that suite launches a browser per case — each case needs a clean extension state, so the launches cannot be shared or parallelised. It no longer fits in the fifteen-minute job ceiling: the base branch's Linux job takes eleven minutes, and this branch adds the real-browser cases on top of it, so the job was killed and the aggregate check failed with it. A ceiling is not a cost — a job that finishes in eleven minutes still finishes in eleven minutes.
The credential store's per-OS mechanisms were rewritten during the review and are in place now. The table said they were "under active revision", which reads as work in progress; what is actually open is the macOS and Windows forms' execution, which is asserted through an injected runner rather than run on those operating systems.
…at it did not The record carried a wrong claim about the CI's ubuntu failure: it said the runner "cannot drive a real browser with the extension". The same job's sibling spec passes in both channels -- the handshake completes in 785 ms in chromium and in 22.6 s in msedge -- so the runner does drive a real browser with the built extension, and the extension does pair with the host. What is true is narrower: the external spec's operations against the live tab never come back. It also carried the wrong verdict for the macOS job. At the tip, the matrix cancelled it when the ubuntu job failed, so it has no verdict at all -- not a pass. It passed at acbf1b4, on the same code. And it did not say where the redness comes from. The same six cases failed the same way on a18c916, the browser-use programme's own head commit, merged as PR #195 and an ancestor of this branch's base: 138 passed, 6 failed, at test:webui-browser. This slice changes neither the extension, nor the bridge server, nor that spec, so the redness is inherited rather than introduced. The record now says the gate ran five times rather than once -- with what each run found -- and separates those runs from the targeted re-runs of a single suite. It names the commit whose subject covers only part of what the commit changes, and why that subject was not rewritten. The review record now carries the invocation itself: the paseo agent, the model, the thinking option, the Codex session, the tokens, and the prompt verbatim, so the review is not only attested by the commit that fixed it.
What this is
The WebUI gains a Browser panel: it shows a browser page as a live image
stream — not an iframe into the target site — and forwards the person's
pointer, keyboard and text input into that same page, so the Agent's browser
tools and the human operate one browser session.
The programme is governed by
packages/webui/docs/adr/0019-webui-browser-use-surfaces-and-external-browser-bridge.md.Three surfaces, four independent switches
isolatedChromiumedgechromefullCdpEvery switch is off unless the configuration says otherwise, and the switches
are independent: allowing the isolated browser never implies allowing the
person's own, and Full CDP widens an already-authorized surface rather than
authorizing one.
The properties that carry the weight
browser service, so "the panel shows one page and the Agent drives another"
is not expressible.
/browser-bridge, its ownWebSocket server, its own origin rule, its own credential. The generic
dev-mode bypass does not reach it — a
dev: trueWebUI still refuses anunauthenticated extension, and there is a test that proves it over a real
socket.
the clear. The host issues a fresh single-use nonce; the extension answers
with an HMAC-SHA256 proof, and the host seals the credential to the
extension's own ephemeral P-256 key. The secret never appears in a process
argument, a log line or an error message — and a test observes that, rather
than asserting it.
the pairing fails explicitly. There is no plaintext file fallback.
build has one identity and a person may load it in both Edge and Chrome, so
the identity alone cannot say which browser is connecting. The peer names the
browser it runs in, and the host answers with that surface's pairing. The
name only selects which secret has to be proved: each surface has its own
installation key and its own credential, the challenge comes from that
surface's pairing, and the proof is checked against that pairing's own
secret — so a peer that calls itself Edge and answers with the Chrome key is
refused.
recorded against a page that has since been replaced is refused, not
delivered to whatever took its place.
credential, admits no session, and refuses its old credential from then on.
the store allowlist and never the development one; an unpacked build's id is
derived from the directory it was loaded from, so it is not an identity
anyone can rely on.
What is in the change
packages/webui/src/shared/contracts/browser.ts,browser-bridge.tspackages/webui/src/runtime/browser/**ws)packages/webui/src/runtime/browser/isolated-chromium/**packages/webui/src/server/browser-bridge/**packages/webui/src/server/operation/browser.ts,handlers/browser.tspackages/webui/src/client/features/browser/**packages/browser-extension/**packages/webui/docs/browser-use/**Verification
The gates that cover this change, and nothing else:
pnpm typecheck:webui— clean.pnpm check:webui-dependency— 0 direction, role or publication violations.pnpm build:webui— clean.pnpm check:webui-boundary— clean.pnpm check:source— 5329 files, workspace exports and native helperintegrity verified; the export preview agrees.
pnpm test:webui— the WebUI's Vitest suite, all files and tests passing.pnpm test:webui-browser— the Playwright suite, all tests passing.pnpm test:webui-package— the npm archive is built, packed, installed andstarted, and its HTTP assets, its shutdown and its bridge's
admission are checked against the running process.
Those runs cover the tree as of
c45c373f. The commits after it are thesource-history fix, the liveness ping, the Edge-case skip, the ADR amendments,
the review record and the CI ceiling below — and the branch's own
pnpm verifyruns at every commit, so the final inputs are covered there aswell.
One change outside the WebUI package
The Linux profile drives the real-browser suite, which launches a browser per
case — each case needs a clean extension state, so the launches cannot be
shared. That suite no longer fits in the workflow's fifteen-minute job
ceiling: the base branch's Linux job takes eleven minutes, and this branch
adds the real-browser cases on top of it. The ceiling is raised to
twenty-five minutes in
.github/workflows/ci.yml. It is a ceiling and not acost — a job that finishes in eleven minutes still finishes in eleven minutes.
The acceptance, against real browsers
packages/webui/docs/browser-use/09-acceptance-record.mdis the publishedmatrix. The suites write the record as they run — the browser version, the
operating system, the settings read from the host, and the observed result —
and the matrix is written from it. Every surface the specification names is
in it:
browser_target_stale.keyand nothing else, a packaged host admits the build, and the same packaged host refuses it through the development allowlist.The adversarial cases
packages/webui/test/unit/browser-bridge-adversarial.test.tsandpackages/webui/test/unit/browser-bridge-credential-secrecy.test.tscover thecases a person on the other side of the socket can produce on purpose: an
oversized frame, a malformed frame, a duplicate connection, a revocation
during an in-flight action, an identity on two surfaces, a peer naming a
browser the host does not know, and a credential reaching a log.
Reviewed
Ten review rounds have been run against this change — nine by an
independent
codexgpt-6-lunareviewer reading the source rather than thedescription, and one final acceptance pass against the finished tree. Every
finding and what was done about each is recorded in
packages/webui/docs/browser-use/07-review-findings.md.Every finding is closed; the ones that changed a decision are recorded in
the decision record's amendments.
What this change does not deliver
Stated here so a reader does not have to find it:
host is Linux and has neither
securitynorpowershell.exe. The threeplatform forms are asserted against the platform command contract through an
injected runner; the Linux form's availability was probed separately and
was unavailable on this host.
matrix is a Linux host. One extension package for both stores is not the
behaviour being the same.
release action. The store identities do not exist yet — a store assigns them
at first publication — so the builds are produced from a public key supplied
at build time.
pass. The tool adapter forwards to the same browser service as the panel,
and a run that has selected nothing is refused rather than being sent
somewhere the person is not looking.
real browser. The bridge carries the DevTools command and the extension's
allowlist refuses what is not in it; the switch that gates it is enforced in
the runtime service and is covered by the unit suite.